#!/usr/bin/python
#
# Copyright 2010 Tipin Chang, tipinchang@gmail.com. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License):
#   http://www.apache.org/licenses/LICENSE-2.0
#
# The software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
# express or implied.
#

'''OpenBatch is a cross-platform batch processing framework based on
OpenDocument.'''

__version__ = '0.0.4'
__author__ = "tipinchang@gmail.com (Tipin Chang)"

import os
import sys
import copy
import codecs
import mimetypes
import platform
import datetime
import smtplib
import mimetypes
from os import path
from email import Encoders
from email.MIMEBase import MIMEBase
from email.MIMEText import MIMEText
from email.MIMEAudio import MIMEAudio
from email.MIMEImage import MIMEImage
from email.MIMEMultipart import MIMEMultipart

#======================================================================
class ObErr(RuntimeError):
    """OpenBatch runtime error object"""
    def __init__(self, errMsg):
        print "Error:%s" % errMsg
        RuntimeError.__init__(self, errMsg)

#======================================================================
class Server:
    """Generic Sever class"""
    def __init__(self):
        self.session = None
    def __del__(self):
        self.close()
    def connect(self, args):
        raise ObErr("~s connect() not implemented" % className(self))
    def close(self):
        raise ObErr("~s close() not implemented" % className(self))
    
#======================================================================
# The following module will be dynamic loaded if used
# from win32com import client
class ComApp(Server):
    LibraryLoaded = False
    def __init__(self, appName):
        # dynamic loading 'win32com' module
        if not ComApp.LibraryLoaded:
            tryImport(("win32com",["client"]), \
"""needed to access Microsoft Office document
  via the COM interface. You can download from
  http://sourceforge.net/projects/pywin32/""")
            ComApp.LibraryLoaded = True
        self.session = None
        self.appName = appName
        self.connect()
        
    def connect(self):
        if self.session != None:
            # close old session first
            self.close()
        self.session = client.Dispatch(self.appName)
        return self

    def close(self):
        if not self.session:
            return
        self.session.Quit()
        self.session = None
        return self
        
#======================================================================
class GmailServer(Server):
    def __init__(self, user, password):
        self.connect(user, password)

    def connect(self, user, password):
        print "connecting to Gmail for '%s'..." % user
        self.user = user
        self.password = password
        # connect to GMAIL SMTP server
        self.session = smtplib.SMTP('smtp.gmail.com', 587)
        self.session.ehlo()
        self.session.starttls()
        self.session.ehlo()
        # login to GMAIL SMTP Server
        self.session.login(user, password)
        return self
        
    def send(self, mail):
        # sanity check for mail object
        if not issubclass(mail.__class__, EmailDoc):
            raise ObErr("send() with non-Email object:'%s'" % mail)
        if self.session == None:
            raise ObErr("need to call connect() before send()")
        # send using sendmail()
        self.session.sendmail(self.user, mail.recipient,
                              mail.toMIME().as_string())
        return self
    
    def close(self):
        if self.session == None:
            return
        self.session.close()
        self.session = None
        return self

#======================================================================
class BaseDoc:
    """Base document class for all OpenBatch document objects."""
    def __init__(self, filePath=None, mimeType=None, encoding=None):
        #print "creating %s object..." % className(self)
        self.data = None
        if filePath != None:
            self.setPath(filePath)
        else:
            self.filePath = None
        # try to figure out MIME type and encoding based on file name
        if filePath != None:
            mimeType2, encoding2 = mimetypes.guess_type(filePath)
            if not mimeType:
                mimeType = mimeType2
            if not encoding:
                encoding = encoding2
        if not mimeType:
            raise ObErr("%s object with unknown MIME file:'%s'"% self.filePath)
        self.mimeType = mimeType
        if not encoding:
            mainType, subType = self.mimeTypes()
            if mainType=='text':
                # text default to UTF-8
                encoding = 'utf-8'
        self.encoding = encoding
        # always read data if filePath is set
        if self.filePath != None:
            self.readData()

    def __str__(self):
        mainType, subType = self.mimeTypes()
        if mainType == 'text':
            # for 'text' type BaseDoc, return self.data as my
            # string representation
            return self.data
        else:
            return object.__str__(self)

    def __iter__(self):
        raise ObErr("""Class %s does not implement an iterable interface.
        See OpenSpreadsheet.__iter__() as an example.""" % \
        className(self))
        
    def replaceText(self, dict):
        """Replace all occurrences of keys in the word document by
        its corresponding value in dictionary, dict, of {key:value,..}"""
        raise ObErr("%s replaceText() not supported" % className(self))

    def saveAs(self, savePath):
        filePath = path.abspath(savePath)
        try:
            file = codecs.open(filePath, "w", encoding=self.encoding)
            print "saving %s object to %s..." % (className(self), filePath)
            file.write(self.data)
            file.close()
        except:
            ObErr("failed to write to '%s'" % filePath)
            raise
        self.filePath = filePath

    def mimeTypes(self):
        return self.mimeType.split('/', 1)

    def toMIME(self):
        if not self.data:
            file = open(self.filePath, 'rb')
            self.data = unicode(file.read())
            file.close()
        # parse MIME main and sub types
        mainType, subType = self.mimeTypes()
        # create different MIME object based on mainType
        if mainType == 'text':
            mime = MIMEText(self.data.encode(self.encoding))
        elif mainType == 'image':
            try:
                mime = MIMEImage(self.data,_subType=subType)
            except TypeError:
                mime = self.createGenericMIME(mainType, subType)
        elif mainType == 'audio':
            try:
                mime = MIMEAudio(self.data,_subType=subType)
            except TypeError:
                mime = self.createGenericMIME(mainType, subType)
        else:
            mime = self.createGenericMIME(mainType, subType)
        # add MIME header information
        mime.add_header('Content-Disposition', 'attachment; filename="%s"'\
                        % os.path.basename(self.filePath))
        return mime

    def createGenericMIME(self, mainType, subType):
        mime = MIMEBase(mainType, subType)
        mime.set_payload(self.data)
        Encoders.encode_base64(mime)
        return mime

    def sendToPrinter(self, printer=None):
        raise ObErr("Class %s does not implement sendToPrinter() interface" % \
                    className(self))

    def readData(self):
        """Read the content from self.filePath using self.encoding
        """
        if not self.filePath:
            raise ObErr("need to call setPath() before calling readData()")
        if not self.encoding:
            file = open(self.filePath, 'rb')
        else:
            file = codecs.open(self.filePath, 'r', encoding=self.encoding)
        self.data = file.read()
        file.close()
        if self.encoding != None:
            self.data = unicode(self.data)
        return self

    def setPath(self, filePath):
        absFilePath = path.abspath(filePath)
        if not path.exists(absFilePath):
            raise ObErr( "file '%s' does not exist" % absFilePath)
        self.filePath = absFilePath
        return self

#======================================================================
class FileDoc(BaseDoc):
    """Generic file-based document. The constructor will 'guess' the
    MIME type of the file based on its file name
    """
    def __init__(self, filePath, mimeType=None):
        BaseDoc.__init__(self, filePath=filePath, mimeType=mimeType)

    def replaceText(self, dict):
        mainType, subType = self.mimeTypes()
        if mainType == 'text':
            # only replace 'text' if I'm a text-based document
            self.data = replaceText(self.data, dict)
        # for binary type file, this is a NOP
        return self
    
#======================================================================
class BaseHTML(BaseDoc):
    """HTML document class"""
    def __init__(self, text=None, filePath=None, encoding='utf-8'):
        BaseDoc.__init__(self, filePath=filePath, mimeType='text/html',
                         encoding=encoding)
        if text:
            # always use UNICODE as internal data
            self.data = unicode(text)

    def replaceText(self, dict):
        self.data = replaceText(self.data, dict)
        return self

    def toMIME(self):
        return MIMEText(self.data.encode(self.encoding), 'html')

    def close(self):
        return self

class StringHTML(BaseHTML):
    """String-based HTML document class"""
    def __init__(self, text):
        BaseHTML.__init__(self, text=text)

class FileHTML(BaseHTML):
    """File-based HTML document class"""
    def __init__(self, filePath):
        BaseHTML.__init__(self, filePath=filePath)

#======================================================================
class EmailDoc(BaseDoc):
    def __init__(self, sender, recipient, subject, body="", attachment=[]):
        BaseDoc.__init__(self, filePath=None, mimeType='message/rfc822')
        ## sanity check sender, recipient, subject types
        self.sender = self.checkField(sender)
        self.recipient = self.checkField(recipient)
        self.subject = self.checkField(subject)
        self.body = self.checkField(body)
        self.attachment = []
        self.attach(attachment)

    def checkField(self, field):
        if field == None:
            field = ''
        # field must be either string or BaseDoc type
        if not (issubclass(field.__class__, str) or
                issubclass(field.__class__, BaseDoc)):
            raise ObErr("bad email field value:'%s'" % field)
        # if a BaseDoc object must have mimeType set to field
        if issubclass(field.__class__, BaseDoc):
            if not field.mimeType:
                raise ObErr("bad email field without mimeType:'%s'" % \
                            field)
            mainType, subType = field.mimeTypes()
            if mainType != 'text':
                raise ObErr("bad email field with non-text mimeType:'%s'" % \
                            field)
            # duplicate the object so that operation on this field will not
            # change the contents of the original object
            field = copy.deepcopy(field)
        else:
            field = unicode(field)
        return field

    def attach(self, attachment):
        if not attachment:
            pass
        elif type(attachment) == type([]):
            for attItem in attachment:
                self.attach(attItem)
        elif issubclass(attachment.__class__,BaseDoc):
            # only allow BaseDoc object as attachment
            # duplicate the object into attachment
            self.attachment.append(copy.deepcopy(attachment))
        else:
            raise ObErr("unknown attachment type:'%s'" % attachment)
        return self

    def replaceText(self, dict):
        self.sender = replaceText(self.sender, dict)
        self.recipient = replaceText(self.recipient, dict)
        self.subject = replaceText(self.subject, dict)
        self.body = replaceText(self.body, dict)
        for attItem in self.attachment:
            attItem.replaceText(dict)
        return self

    def toMIME(self):
        # create a multipart MIME object
        mail = MIMEMultipart()
        mail['From'] = toUnicodeStr(self.sender)
        mail['To'] = toUnicodeStr(self.recipient)
        mail['Subject'] = toUnicodeStr(self.subject)
        # handle mail body (text or HTML)
        if type(self.body) == type(""):
            mail.attach(MIMEText(self.body, 'plain'))
        elif issubclass(self.body.__class__, BaseDoc):
            mail.attach(self.body.toMIME())
        else:
            raise ObErr("unknown type Email body:'%s'" % self.body)
        # handle attachments if any
        if type(self.attachment) != type([]):
            raise ObErr("bad attachment:'%s'" % self.attachment)
        # add attachments one by one
        for item in self.attachment:
            mail.attach(item.toMIME())
        # return the resulting multipart MIME object
        return mail

#======================================================================
class WordDoc(BaseDoc):
    """Document class for Microsoft Word document objects"""
    FindContinue = 1
    ReplaceAll = 2
    ComApp = None
    def __init__(self, filePath):
        BaseDoc.__init__(self, filePath=filePath,
                         mimeType='application/msword')
        
    def readData(self):
        # start the COM server if not started yet
        if not WordDoc.ComApp:
            WordDoc.ComApp = ComApp("Word.Application")
        self.session = WordDoc.ComApp.session
        # check if need to close current doc
        if self.data:
            self.close()
        # invoke parent class constructor
        self.data = self.session.Documents.Open(self.filePath)
        return self

    def __del__(self):
        self.close()
        
    def close(self):
        if not self.data:
            return
        self.data.Close()
        self.data = None
        return self

    def replaceText(self, dict):
        select = self.session.Selection
        find = select.Find
        for key, value in dict.items():
            # Execute(FindText, MatchCase, MatchWholeWord, MatchWildcards,
            #         MatchSoundsLike, MatchAllWordForms, Forward, 
            #         Wrap, Format, ReplaceWith, Replace)
            find.Execute(key, True, False, False, False, False, True, \
                         WordDoc.FindContinue, False, value, \
                         WordDoc.ReplaceAll)
        return self

    def saveAs(self, savePath):
        """Save the word document in to a different file name"""
        filePath = path.abspath(savePath)
        print "saving changed '%s' as '%s'..." % (self.filePath, filePath)
        self.session.ActiveDocument.SaveAs(filePath)
        self.filePath = filePath
        return self

class ExcelDoc(BaseDoc):
    """Document class for Microsoft Word document objects"""
    def __init__(self, filePath):
        raise NotImplemented

#======================================================================
# The following module will be dynamic loaded if used
# from odf import opendocument, text
# from odf.opendocument import Spreadsheet
# from odf.table import TableRow, TableCell
class OpenDoc(BaseDoc):
    """Base class for all OpenOffice documents"""
    Loaded = False
    def __init__(self, filePath, mimeType):
        # check if need to load the odf modules
        if not OpenDoc.Loaded:
            tryImport([("odf", ["opendocument", "text"]),
                       ("odf.opendocument", ["Spreadsheet"]),
                       ("odf.table",["TableRow","TableCell"]), ], \
"""needed to parse OpenOffice documents. You can download from
  http://opendocumentfellowship.com/projects/odfpy""")
            OpenDoc.Loaded = True
        # invoke parent class constructor
        BaseDoc.__init__(self, filePath=filePath, mimeType=mimeType)

    def readData(self):
        # load the OpenOffice document
        self.data = opendocument.load(self.filePath)
        return self

    def saveAs(self, savePath):
        filePath = path.abspath(savePath)
        file = open(filePath, "w")
        print "saving %s object to %s..." % (className(self), filePath)
        self.data.save(file)
        file.close()
        self.filePath = filePath
        return self

    def close(self):
        # NOP
        return self
        
#----------------------------------------------------------------------
class OpenTextDoc(OpenDoc):
    """Class for OpenOffice Text document objects"""
    def __init__(self, filePath):
        OpenDoc.__init__(self, filePath,
                         "application/vnd.oasis.opendocument.text")

    def replaceText(self, dict):
        for paragraph in self.data.getElementsByType(text.P):
            replaceTextNode(paragraph, dict)
        return self
        
#----------------------------------------------------------------------
class OpenSpreadsheet(OpenDoc):
    """Class for OpenOffice Spreadsheet objects"""
    def __init__(self, filePath):
        OpenDoc.__init__(self, filePath,
                         "application/vnd.oasis.opendocument.spreadsheet")

    def __iter__(self):
        """OpenSpreadsheet iterator behavior init method"""
        # parse the spreadsheet into list of dictionaries of
        # [{ColName:ColValue,...},...]
        self.rows = parseSpreadsheet(self.data)
        return self
    
    def next(self):
        """OpenSpreadsheet iterator behavior next() method"""
        if len(self.rows) == 0:
            raise StopIteration
        row = self.rows[0]
        self.rows = self.rows[1:]
        return row

    def replaceText(self, dict):
        replaceTextNode(self.data.spreadsheet, dict)
        return self


#======================================================================
# Utility Functions
#======================================================================
def tryImport(module, errStr=None):
    """Try to import 'module', if not found, print out error message
    'errStr'"""
    # check if 'module' is a list of modules
    if type(module) == type([]):
        for moduleItem in module:
            tryImport(moduleItem, errStr)
        return
    try:
        if type(module) == type(()):
            # module is a tuple of (moduleName, objNames)
            # similar to statement "from <moduleName> import <objNames>"
            moduleName, objNames = module
            namespace = __import__(moduleName, fromlist=objNames).__dict__
            for objName in objNames:
                if not namespace.has_key(objName):
                    raise ObErr("failed to lookup object '%s'" % objName)
                globals()[objName] = namespace[objName]
        else:
            # module is moduleName (e.g. "a.b.c")
            # similar to statement "import <moduleName>"
            # Need to add a fake fromlist argument to force __import__
            #   to import the full path in moduleName
            moduleName = module
            imported = __import__(moduleName, fromlist=["*"])
            globals()[moduleName] = imported
    except ImportError, KeyError:
        if errStr:
            raise ObErr("Failed to improt module '%s':%s" %\
                        (moduleName, errStr))
        else:
            raise ObErr("Failed to improt module '%s'" % (moduleName))

def parseSpreadsheet(ods):
    """parse an OpenDocument spreadsheet .ods file, and return a dictionary of
    {SpreadSheetName:[Row,..],..} where Row is another dictionary of
    {ColName:ColValue,..}"""
    dict = {}
    root = ods.spreadsheet
    array = []
    for row in root.getElementsByType(TableRow):
        arrayRow = []
        for cell in row.getElementsByType(TableCell):
            value = getTextValue(cell)
            # Special handling of repeated cell
            repeat = cell.getAttribute(u'numbercolumnsrepeated')
            #cell.toXml(1, sys.stdout)
            if repeat is None:
                arrayRow.append(value)
            else:
                # append "repeated" values
                arrayRow += [value for i_ in range(int(repeat))]
        array.append(arrayRow)
    return rowsToDicts(array)
    
def getTextValue(node):
    """Return the Text node value within the XML 'node'"""
    if node.nodeType == node.TEXT_NODE:
        return node.data
    textValue = ""
    for child in node.childNodes:
        textValue += getTextValue(child)
    return textValue

def rowsToDicts(rows):
    """Convert 'rows' into a list of dictionary of
    [{ColName:ColValue,..},..]"""
    # Column values the 1st row are the column names
    titles = []
    hasEmptyCell = False
    cellId = 1
    for cell in rows[0]:
        if cell == '':
            hasEmptyCell = True
        elif hasEmptyCell:
            raise ObErr("Title row found gap in cell#%d value:'%s'" %\
                        (cellId, cell))
        else:
            titles.append(cell)
    dicts = []
    rowId = 2
    for row in rows[1:]:
        dict = {}
        # skip empty row
        if filter(lambda c_: c_ != '', row) == []:
            continue
        # normalize row into same size as titles
        if len(row) < len(titles):
            # append '' to missing cells in row
            row += ['' for i_ in range(len(titles)-len(row))]
        elif len(row) > len(titles):
            # check any non empty cell beyond titles
            cellId = len(titles) + 1
            for cell in row[len(titles):]:
                if cell != '':
                    raise ObErr("row#%d found out-of-range cell#%d value:'%s'"\
                                (rowId, cellId, cell))
                cellId += 1
            # trucate extra empty cells in row
            row = row[len(titles)]
        # start converting row into {title:cell,..}
        for i in range(len(titles)):
            dict[titles[i]] = row[i]
        rowId += 1
        dicts.append(dict)
    return dicts

def replaceTextNode(node, dict):
    """Replace all occurrences of key in all the Text node within 'node'
    by its corresponding value in the dictionary 'dict'"""
    if node.nodeType==node.TEXT_NODE:
        str = replaceText(node.data, dict)
        if str != node.data:
            node.data = str
        else:
            # ???try to compensate a weir bug in odf module, which 'eats'
            # up the leading and ending space of a text segment
            node.data = ' ' + str + ' '
    else:
        for child in node.childNodes:
            replaceTextNode(child, dict)
    return node
            
def replaceText(text, dict):
    """Replace all occurrences of key in all 'text' by its corresponding
    value in the dictionary 'dict'
    """
    if text==None:
        return None
    elif issubclass(text.__class__, BaseDoc):
        text.replaceText(dict)
        # return the replace object
        return text
    elif type(text) != type(u"") and type(text) != type(""):
        raise ObErr("replaceText() bad text object:'%s'" % text)
    oldText = text
    for key,value in dict.items():
        text = text.replace(key, value)
    if text==oldText:
        return text
    else:
        return replaceText(text, dict)

def toUnicodeStr(textable):
    """Convert a 'textable' object into UNICODE string"""
    if type(textable) == type(""):
        return unicode(textable)
    elif type(textable) == type(u""):
        return textable
    elif issubclass(textable.__class__, BaseDoc):
        return unicode(str(textable))
    else:
        raise ObErr("non unicodable text:'%s'" % textable)

def className(object):
    """Return the class name of the object
    """
    if not object:
        return None
    else:
        return object.__class__.__name__
